spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { Download, ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { t } from '@/i18n';6import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';7import { apiAnalytics } from '@/lib/api-analytics';8import { apiCompare } from '@/lib/api-compare';9import { grouped } from '@/lib/format';10import { parseRankingState, type RankingState } from '@/lib/ranking-state';11import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';12import { routes } from '@/lib/site';13import { topicById } from '@/lib/topics';14import type { RankingResponse } from '@/lib/types';15import { toCountryLite, type CountryLite } from '@/lib/types-compare';16import { RankRace } from '@/components/charts/rank-race';17import { NotBuiltState } from '@/components/data/empty-state';18import { Section } from '@/components/data/section';19import { baseFeatures } from '@/components/indicators/map-geometry';20import { RankHistoryPanel } from '@/components/rankings/rank-history-panel';21import { RankingView } from '@/components/rankings/ranking-view';2223export const revalidate = 900;2425type Params = { indicator: string };26type SP = Record<string, string | string[] | undefined>;27/** Indicators with a rank race on their page (long histories, ≥ 30 ranked years). */28const RACE_SLUGS = new Set(['gdp', 'gdp-ppp', 'population', 'gdp-per-capita', 'gdp-per-capita-ppp', 'life-expectancy', 'co2-emissions', 'co2-per-capita', 'internet-users', 'exports-goods-services', 'urban-population-share', 'fertility-rate', 'median-age']);2930async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> {31 if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;32 try {33 return await apiCompare.ranking(slug, { year: state.year, group: state.group, sort: state.sort, limit: 300, sparkline: true });34 } catch (e) {35 if (isNotFound(e)) return null;36 if (isNotBuilt(e)) return 'not-built';37 throw e;38 }39}4041export async function generateMetadata({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }): Promise<Metadata> {42 const [{ indicator }, sp] = await Promise.all([params, searchParams]);43 const state = parseRankingState(sp);44 const data = await load(indicator, state);45 if (!data || data === 'not-built') return { title: t('ranking.notFound'), robots: { index: false } };46 const name = data.indicator.short_name ?? data.indicator.name ?? indicator;47 const year = data.year_used ?? '';48 const world = state.group === 'world';49 const title = world ? seoTitle.ranking(name, year) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year });50 const top = data.rows51 .slice(0, 3)52 .map((r) => r.country.name)53 .filter(Boolean)54 .join(', ');55 const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top });56 const canonical = routes.ranking(data.indicator.slug, { group: state.group });57 const volatile = state.year != null || state.highlight || state.q || state.income || state.minpop || state.mincov || state.view !== 'table';58 return {59 title: { absolute: `${title} | ${t('site.name')}` },60 description,61 alternates: { canonical },62 robots: volatile ? { index: false, follow: true } : undefined,63 openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },64 twitter: { card: 'summary_large_image', title, description },65 };66}6768export default async function RankingPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {69 const [{ indicator }, sp] = await Promise.all([params, searchParams]);70 const state = parseRankingState(sp);71 const data = await load(indicator, state);72 if (data === null) notFound();73 if (data === 'not-built') return <NotBuiltState />;7475 const ind = data.indicator;76 const name = ind.short_name ?? ind.name ?? indicator;77 const year = data.year_used;78 const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null;79 const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5);80 const wantRace = RACE_SLUGS.has(ind.slug) || !!ind.featured;8182 const [regions, countriesRes, history, race] = await Promise.all([83 safe(apiCompare.regions()),84 safe(api.countries()),85 historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null),86 wantRace ? safe(apiAnalytics.race(ind.slug, { top: 10, group: state.group !== 'world' ? state.group : null })) : Promise.resolve(null),87 ]);88 const all = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate');89 const countries: CountryLite[] = all.map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));90 const { features, sphere } = all.length ? baseFeatures(all) : { features: [], sphere: '' };91 const topic = topicById(ind.topic ?? '');92 const groupName = data.group.name ?? state.group;93 const spec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, name, higher_is_better: ind.higher_is_better };94 const showRace = race && race.frames.length >= 30;95 const ld = [jsonLd.breadcrumbs([{ name: t('rankings.title'), path: routes.rankings() }, { name, path: routes.ranking(ind.slug) }]), jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, unit: ind.unit, nCountries: data.n, modified: data.meta.built_at })];9697 return (98 <>99 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />100 <header className="pb-3 pt-6 md:pt-10">101 <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0">102 <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">103 {t('rankings.title')}104 </Link>105 {topic ? (106 <>107 <span className="mx-1.5">/</span>108 <Link href={`${routes.rankings()}#topic-${topic.id}`} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">109 {topic.short}110 </Link>111 </>112 ) : null}113 </nav>114 <h1 className="display mt-2 text-3xl leading-tight text-ink md:text-4xl">{state.group === 'world' ? t('ranking.heading', { name }) : t('ranking.headingGroup', { name, group: groupName })}</h1>115 <p className="tnum mt-2 text-sm text-ink-2">116 {ind.name && ind.name !== name ? <span className="text-ink">{ind.name} · </span> : null}117 {t('ranking.definition', { unit: ind.unit ?? '', n: grouped(data.n), year: year ?? t('common.na') })}118 </p>119 <div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm">120 <Link href={routes.indicator(ind.slug)} className="inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline md:min-h-[32px]">121 {t('ranking.openIndicator')}122 <ExternalLink size={13} aria-hidden />123 </Link>124 <Link href={routes.explore({ indicator: ind.slug, year })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">125 {t('home.map.openExplorer')}126 </Link>127 <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">128 <Download size={13} aria-hidden />129 {t('ranking.download')}130 </a>131 {data.rows.length >= 2 ? (132 <Link href={routes.compare(...data.rows.slice(0, Math.min(5, data.rows.length)).map((r) => r.country.slug ?? r.country.id.toLowerCase()))} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">133 {t('ranking.compareTop', { n: Math.min(5, data.rows.length) })}134 </Link>135 ) : null}136 </div>137 </header>138139 <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} geometry={features} sphere={sphere} />140141 {showRace ? (142 <Section id="race" title={t('ranking.race.title')} subtitle={t('ranking.race.sub', { name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}>143 <RankRace data={race} spec={spec} top={10} initialYear={year} />144 </Section>145 ) : null}146147 <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={showRace ? 3 : 2}>148 <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} />149 </Section>150 </>151 );152}153